Skip to content

Conversation

@LiveLaughLove13
Copy link
Contributor

@LiveLaughLove13 LiveLaughLove13 commented Dec 23, 2025

Kaspa Stratum Bridge (BridgeBinary) is a standalone Stratum bridge for Kaspa built on top of the [rusty-kaspa] stack.

  • Accepts Stratum connections from ASIC miners.
  • Fetches block templates from a Kaspa node via gRPC.
  • Validates and submits found blocks to the node.
  • Exposes detailed console stats and Prometheus metrics.

The bridge supports two node modes:

External mode – connects to an already running [kaspad] node.
In‑process mode – starts an embedded [kaspad] instance inside the bridge process, then talks to it via gRPC.
Both modes share the same mining logic and stats; only node lifecycle is different.

Configuration

The bridge is configured via a YAML file, typically:

components/kaspa-stratum-bridge/config.yaml

Key fields:

Global section
kaspad_address: gRPC address of the Kaspa node, e.g. "127.0.0.1:16110".
Instances section
One or more Stratum instances, each with:
stratum_port: e.g. ":5555", ":5556", …
Difficulty / var‑diff configuration.
Optional Prometheus port and logging options.
The YAML is parsed into:

GlobalConfig – shared settings for all instances.
InstanceConfig – per‑instance Stratum configuration.
BridgeConfig – contains global and instances and is passed into the bridge startup logic.
Command‑line interface
Binary: stratum-bridge (or kaspa-stratum-bridge)

Important CLI options (from src/main.rs):

--config <PATH>
Path to the YAML configuration.
--node-mode <external|inprocess>
external: connect to an already running kaspad.
inprocess: start an embedded kaspad node.

**If omitted:**
If any --node-args / --node-arg are supplied → inprocess is inferred.
Otherwise → external is inferred.
--node-args "<ARGS>"

Quoted string of arguments passed directly to kaspad in in‑process mode.
--node-arg <ARG> (repeatable)

Alternate way to supply individual node args.
**External node mode**
In external mode, the bridge runs as a pure gRPC client. You start kaspad yourself, then run the bridge and point it to the node via kaspad_address in config.yaml.

1. Start kaspad separately
Example (mainnet, with high peer/RPC capacity and perf metrics):

bash
cargo run --release --bin kaspad -- --utxoindex --listen=0.0.0.0:16111 --ram-scale=3 --rpclisten=127.0.0.1:16110 --rpclisten-borsh=127.0.0.1:17110 --rpclisten-json=127.0.0.1:18110 --outpeers=128 --maxinpeers=512 --rpcmaxclients=512 --perf-metrics --perf-metrics-interval-sec=1 --yes

**Important:**

--rpclisten must match kaspad_address in components/kaspa-stratum-bridge/config.yaml.
2. Start the bridge
PowerShell example (one logical command):

powershell
cargo run -p kaspa-stratum-bridge --release --bin stratum-bridge -- `
  --config components/kaspa-stratum-bridge/config.yaml `
  --node-mode external
Or on a single line (any shell):

bash
cargo run -p kaspa-stratum-bridge --release --bin stratum-bridge -- --config components/kaspa-stratum-bridge/config.yaml --node-mode external
If --node-mode is omitted and no --node-args are supplied, the bridge also infers external mode.

In‑process node mode
In in‑process mode, the bridge:

Parses kaspad CLI arguments (kaspad::args::Args).
Starts an embedded node via kaspad_daemon::create_core_with_runtime.
Connects to that node via gRPC at kaspad_address from the config.
The mining and stats behavior is the same as in external mode; only the node lifecycle differs.

**Example command (PowerShell)**
powershell

cargo run -p kaspa-stratum-bridge --release --bin stratum-bridge -- --config bridge/config.yaml --node-mode inprocess --node-args="--utxoindex --rpclisten=127.0.0.1:16110 --rpclisten-borsh=127.0.0.1:17110 --perf-metrics --perf-metrics-interval-sec=1 --outpeers=128 --disable-upnp "

Notes:

In PowerShell, use backtick ` for line continuation, or keep the command on one line.
If --node-mode is omitted but --node-args is present, the bridge infers inprocess mode.
--rpclisten must match kaspad_address in config.yaml (e.g. 127.0.0.1:16110).
Embedded node lifecycle (InProcessNode)
The embedded node is represented by:

rust
struct InProcessNode {
    core: Arc<kaspa_core::core::Core>,
    workers: Vec<std::thread::JoinHandle<()>>,
}
Startup (simplified):

rust
impl InProcessNode {
    fn start_from_args(args: kaspad_args::Args) -> Result<Self, anyhow::Error> {
        // Raise soft FD limit to match kaspad main, avoiding negative fd_budget
        let _ = fd_budget::try_set_fd_limit(kaspad_daemon::DESIRED_DAEMON_SOFT_FD_LIMIT);
        let runtime = kaspad_daemon::Runtime::from_args(&args);
        let fd_total_budget =
            fd_budget::limit()
            - args.rpc_max_clients as i32
            - args.inbound_limit as i32
            - args.outbound_target as i32;
        let (core, _) = kaspad_daemon::create_core_with_runtime(&runtime, &args, fd_total_budget);
        let workers = core.start();
        Ok(Self { core, workers })
    }
    fn shutdown(self) {
        self.core.shutdown();
        self.core.join(self.workers);
    }
}


**Key points:**

Uses the same initialization path as the standalone kaspad binary (create_core_with_runtime).
FD soft limit is raised before computing fd_total_budget, eliminating the fd_budget has to be positive panic with large peer/RPC settings.
Worker threads run inside the bridge process; shutdown is coordinated and blocking via core.shutdown() + core.join(...).
After InProcessNode starts, the bridge constructs a KaspaApi connected to grpc://<kaspad_address>, just like in external mode.

Kaspa API integration (KaspaApi)
File: components/kaspa-stratum-bridge/src/kaspaapi.rs

KaspaApi is a gRPC client wrapper used by the Stratum server to interact with the Kaspa node.

**Responsibilities**
Manage a kaspa_grpc_client::GrpcClient.
Provide high‑level operations:
get_block_template
submit_block
get_balances_by_addresses
Wait for node sync before starting the bridge.
Periodically fetch network stats and DAG info for Prometheus and console output.
Maintain a NODE_STATUS snapshot for display in the [NODE] ... line.
Connection setup
rust
let grpc_address = if address.starts_with("grpc://") {
    address.clone()
} else {
    format!("grpc://{}", address)
};
tracing::debug!("[API] Establishing RPC connection to Kaspa node: {}", grpc_address);
let client = Arc::new(
    GrpcClient::connect_with_args(
        NotificationMode::Direct,
        grpc_address.clone(),
        None,        // subscription_context
        true,        // reconnect: auto reconnection with re‑subscribed notifications
        None,        // no connection event channel
        false,       // override_handle_stop_notify = false
        Some(500_000), // request timeout (ms) - larger than default to reduce spurious timeouts
        Default::default(), // counters
    )
    .await
    .context("Failed to connect to Kaspa node")?,
);
// Start RPC services (notification collector)
client.start(None).await;
// Subscribe to NewBlockTemplate notifications
client
    .start_notify(ListenerId::default(), NewBlockTemplateScope {}.into())
    .await
    .context("Failed to subscribe to block template notifications")?;
Sync and background tasks
After connecting:

wait_for_sync(true):
Periodically calls client.get_sync_status().
Logs and waits until the node reports is_synced = true.
Starts background tasks:
start_stats_thread():
Every 30 seconds:
Calls get_block_dag_info_call and estimate_network_hashes_per_second_call.
Updates Prometheus network stats (hashrate, block count, difficulty).
start_node_status_thread():
Every 10 seconds:
Calls get_server_info_call, get_block_dag_info_call, get_connected_peer_info_call, get_info_call.
Updates NODE_STATUS (is_synced, network_id, server_version, peers, block/header counts, difficulty, tip hash, mempool size).
RPC methods used by Stratum:
get_block_template:
Wraps client.get_block_template_call(...).
Converts RpcRawBlock → Block and validates header serialization.
Retries transient errors up to 3 times, with special handling for malformed hex / “Odd number of digits” issues.
submit_block:
Wraps client.submit_block_call(...).
Logs detailed acceptance/rejection reasons at info/warn/error.
Because both in‑process and external modes use the same KaspaApi, the Stratum logic does not need to care which mode is in use.

**Stratum server and share handling**
Key files:

components/kaspa-stratum-bridge/src/stratum_server.rs
components/kaspa-stratum-bridge/src/share_handler.rs
Stratum server
For each InstanceConfig, the bridge:
Starts a TCP listener on instance.stratum_port, e.g. :5555, :5556, etc.
Creates a ShareHandler associated with an instance_id (e.g. Ins01, Ins02).
Wires in an Arc<dyn KaspaApiTrait + Send + Sync> implemented by KaspaApi.
Share handler
ShareHandler:

Accepts and validates shares from miners:
Parses stratum requests.
Manages per‑worker state (job IDs, difficulty, extranonce, etc.).
Uses KaspaApiTrait to:
Fetch fresh block templates.
Submit valid blocks to the Kaspa node.
Maintains work statistics:
Shares per minute vs target.
Hashrates per worker and per instance.
Accepted / stale / invalid share counts.
Periodically prints a table:
text
+------------------+-------+-------------+--------+-------------+------+--------------+--------+---------+
| Worker           | Inst  |        Hash |   Diff |     SPM/tgt | Trnd |  Acc/Stl/Inv | Blocks |    Time |
+------------------+-------+-------------+--------+-------------+------+--------------+--------+---------+
| ks2lite          | Ins01 |    2.11TH/s |   2048 |   14.4/20.0 | down |      339/0/0 |      0 |   23.6m |
...
| TOTAL            | ALL   |    7.41TH/s |      - |   29.0/20.0 | -    |      686/0/0 |      0 |   23.7m |
+------------------+-------+-------------+--------+-------------+------+--------------+--------+---------+
The share handling code is identical regardless of node mode; it depends only on the KaspaApiTrait abstraction.


Both the bridge and the node share a process.
The bridge still talks to the node via gRPC (loopback), using the same KaspaApi implementation as external mode.
Summary
kaspa-stratum-bridge (BridgeBinary) is the Stratum front‑end for Kaspa in the rusty-kaspa repo.
It supports:
External mode: connects to a separately running kaspad.
In‑process mode: embeds kaspad in the same process and then connects via gRPC.
The technical integration with rusty-kaspa reuses:
kaspad’s Args, Runtime, and create_core_with_runtime for in‑process startup.
kaspa-grpc-client + kaspa-rpc-core for all gRPC interactions.
Shared mining logic in stratum_server.rs and share_handler.rs, abstracted behind KaspaApiTrait.



@LiveLaughLove13 LiveLaughLove13 changed the title Bridge binary (Sanity Test) Kaspa Stratum Bridge binary Dec 27, 2025
Inprocess,
}

fn split_shell_words(input: &str) -> Result<Vec<String>, anyhow::Error> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for? I'm not clear what it's actually doing, but it's strange that it is trying to parse things char by char

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used to parse the --node-args "" CLI option into a proper argv vector for kaspad_args::Args::parse. It implements minimal shell-style tokenization: splits on whitespace, preserves quoted segments ("..." / '...') as a single argument, and errors on unterminated quotes. The char-by-char scan is just to track “inside quotes” state; .split_whitespace() would break quoted args.

Since we did not add args to the kaspad, section directly as requested not to edit the kaspad area.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is it possible to pass through arguments from command line to the inprocess node without using --node-args?

Something like:

./bridge --appdir=/dir/blah --disable-upnp

And these would work equivalent to as if you directly called:

./kaspad --appdir=/dir/blah --disable-upnp

Even an approach where these arguments are copied as needed to struct Cli could work better than doing --node-args this way.

What do you think of this approach?

Comment on lines 186 to 209
tracing::info!("----------------------------------");
tracing::info!("initializing bridge ({} instance{})", instance_count, if instance_count > 1 { "s" } else { "" });
tracing::info!("\tkaspad: {} (shared)", config.global.kaspad_address);
tracing::info!("\tblock wait: {:?}", config.global.block_wait_time);
tracing::info!("\tprint stats: {}", config.global.print_stats);
tracing::info!("\tvar diff: {}", config.global.var_diff);
tracing::info!("\tshares per min: {}", config.global.shares_per_min);
tracing::info!("\tvar diff stats: {}", config.global.var_diff_stats);
tracing::info!("\tpow2 clamp: {}", config.global.pow2_clamp);
tracing::info!("\textranonce: auto-detected per client");
tracing::info!("\thealth check: {}", config.global.health_check_port);

for (idx, instance) in config.instances.iter().enumerate() {
tracing::info!("\t--- Instance {} ---", idx + 1);
tracing::info!("\t stratum: {}", instance.stratum_port);
tracing::info!("\t min diff: {}", instance.min_share_diff);
if let Some(ref prom_port) = instance.prom_port {
tracing::info!("\t prom: {}", prom_port);
}
if let Some(log_to_file) = instance.log_to_file {
tracing::info!("\t log to file: {}", log_to_file);
}
}
tracing::info!("----------------------------------");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor this initial log into it's own function in this file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactored

let exe_base = std::env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf()));
let exe_root = exe_base.as_ref().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.to_path_buf());

let mut candidates: Vec<std::path::PathBuf> = vec![config_path.to_path_buf(), fallback_path.clone()];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these candidates for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

candidates vector contains multiple paths where the bridge will look for the config.yaml file. This ensures the bridge can find its configuration in different deployment scenarios:

Development: Running from different directories in the repo
Deployment: Different installation layouts inprocess versus external
Flexibility: Users can place config in various logical locations
Robustness: Bridge finds config regardless of how it's launched
The bridge iterates through these candidates and uses the first one that exists, making it resilient to different directory structures and deployment scenarios.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, can you refactor these into a fn initialize_config() -> BridgeConfig (Currently L152 - L184) so that the remaining code in main is something like:

  let config = initialize_config();

ulti-instance duplicate block mitigation (multi-port).
Observed occasional duplicate block logs when ASICs were split across multiple stratum ports, not reproducible when all ASICs shared a single port. Root cause is that extranonce allocation was per ClientHandler (per instance), so miners on different ports could receive identical extranonces and mine overlapping nonce space for the same template.
Update: extranonce allocation is now process-global while keeping existing miner expectations intact (Bitmain still gets extranonce_size=0; IceRiver/BzMiner/Goldshell still get 2-byte extranonce).
- **External** node (you run `kaspad` yourself)
- **In-process** node (the bridge starts `kaspad` in the same process)

The bridge no longer supports spawning `kaspad` as a subprocess.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this line. It's not relevant since there was never a release that spawned it as a subprocess.

Make sure to go through this README and check that everything makes sense from a first release POV.

Inprocess,
}

fn split_shell_words(input: &str) -> Result<Vec<String>, anyhow::Error> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is it possible to pass through arguments from command line to the inprocess node without using --node-args?

Something like:

./bridge --appdir=/dir/blah --disable-upnp

And these would work equivalent to as if you directly called:

./kaspad --appdir=/dir/blah --disable-upnp

Even an approach where these arguments are copied as needed to struct Cli could work better than doing --node-args this way.

What do you think of this approach?

node_args: Option<String>,

#[arg(long, action = clap::ArgAction::Append)]
node_arg: Vec<String>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a node_args and node_arg? Although, I think with the suggestion to copy relevant arguments to cli, you can just get rid of both of these.

Comment on lines 148 to 149
let inferred_mode = NodeMode::Inprocess;
let node_mode = cli.node_mode.unwrap_or(inferred_mode);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let inferred_mode = NodeMode::Inprocess;
let node_mode = cli.node_mode.unwrap_or(inferred_mode);
let node_mode = cli.node_mode.unwrap_or(NodeMode::Inprocess);

let exe_base = std::env::current_exe().ok().and_then(|p| p.parent().map(|p| p.to_path_buf()));
let exe_root = exe_base.as_ref().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|p| p.to_path_buf());

let mut candidates: Vec<std::path::PathBuf> = vec![config_path.to_path_buf(), fallback_path.clone()];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, can you refactor these into a fn initialize_config() -> BridgeConfig (Currently L152 - L184) so that the remaining code in main is something like:

  let config = initialize_config();

let listener =
TcpListener::bind(&addr_str).await.map_err(|e| format!("failed listening to socket {}: {}", self.config.port, e))?;

tracing::debug!("Stratum listener started on {}", self.config.port);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is tracing debug used directly, while others are in use tracing::{error, info, warn};? Please make it consistent, and please make sure to apply this to all files where such inconsistency lies. Just add debug to the use tracing::{error, info, warn}; line in this case.

.unwrap();

let _handle = log4rs::init_config(config).unwrap();
let _handle = log4rs::init_config(config).ok();
Copy link
Collaborator

@coderofstuff coderofstuff Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is needed because when running the bridge in inprocess mode, when this is set to unwrap it will panic at this call (or is it somewhere else that panics?)

But why does it? What is happening on the bridge side that makes this call panic? When the node is ran on its own, it will unwrap cleanly.


## Stratum Bridge

Check out the [README.md](bridge/docs/README.md) for instructions on how to run the stratum bridge.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should have a beta warning

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stratum Bridge changed to ## Stratum Bridge Beta

@@ -0,0 +1,73 @@
## Stratum Bridge

This repository contains a standalone Stratum bridge binary at:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doc should have a beta warning

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stratum Bridge changed to ## Stratum Bridge Beta

@coderofstuff coderofstuff merged commit a81c1db into kaspanet:master Jan 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants